﻿/**
 * Copyright 2010 Dave Keen
 * http://www.actionscriptdeveloper.co.uk
 * 
 * This file is part of Flextrine.
 * 
 * Flextrine is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 * 
 * You should have received a copy of the GNU General Public License
 * and the Lesser GNU General Public License along with this program.
 * If not, see <http://www.gnu.org/licenses/>.
 * 
 */

package org.davekeen.flextrine.orm {
	import flash.events.EventDispatcher;
	import flash.media.Video;
	import flash.utils.Dictionary;
	import flexunit.utils.Collection;
	import mx.collections.ArrayCollection;
	import mx.events.CollectionEvent;
	import mx.events.CollectionEventKind;
	import mx.events.PropertyChangeEvent;
	import mx.logging.ILogger;
	import mx.logging.Log;
	import mx.rpc.AsyncToken;
	import mx.utils.ObjectUtil;
	import mx.utils.UIDUtil;
	import org.davekeen.collections.IndexedArrayCollection;
	import org.davekeen.flextrine.orm.metadata.MetaTags;
	import org.davekeen.flextrine.util.ClassUtil;
	import org.davekeen.flextrine.util.Closure;
	import org.davekeen.flextrine.util.EntityUtil;
	/**
	 * ...
	 * @author Dave Keen
	 */
	public class EntityRepository implements IEntityRepository {
		
		public static const STATE_NEW:String = "state_new";
		public static const STATE_MANAGED:String = "state_managed";
		public static const STATE_REMOVED:String = "state_removed";
		public static const STATE_DETACHED:String = "state_detached";
		
		private var em:EntityManager;
		private var entityClass:Class;
		
		[Bindable]
		public var entities:IndexedArrayCollection;
		
		/**
		 * This maintains a map of temporary uids (for persisted objects without real ids) to the objects themselves
		 */
		private var temporaryUidMap:Object;
		
		/**
		 * Keep a dictionary of persisted entities so we can make sure we don't persist the same entity twice
		 */
		private var persistedEntities:Dictionary;
		
		/**
		 * Standard flex logger
		 */
		private var log:ILogger;
		
		/**
		 * This flag is used to differentiate between entity changes within the repository (which should not mark objects as dirty) and changes from elsewhere
		 */
		private static var isUpdating:Boolean;
		
		public function EntityRepository(em:EntityManager, entityClass:Class) {
			this.em = em;
			this.entityClass = entityClass;
			
			temporaryUidMap = new Object();
			persistedEntities = new Dictionary(true);
			
			entities = new IndexedArrayCollection();
			
			// TODO: This needs to check the [Id] metadata, and also any extra [Index] metadata
			entities.addIndex( [ "id" ] );
			
			// Get a logger for this class
			log = Log.getLogger(ClassUtil.getQualifiedClassNameAsString(this));
		}
		
		public function clear():void {
			entities.removeAll();
			temporaryUidMap = new Object();
			persistedEntities = new Dictionary(true);
		}
		
		/**
		 * Get the state of an entity.  An entity which is not yet in a repository is NEW, an object which is in a repository is MANAGED, and object which is an
		 * identified entity but isn't in a repository is DETACHED and an object which is scheduled for removal is REMOVED.
		 * 
		 * @param	entity
		 * @return
		 */
		public function getEntityState(entity:Object):String {
			if (ClassUtil.getClass(entity) !== entityClass)
				throw new Error("This is not the correct repository for " + entity + " {entity class = " + ClassUtil.getClassAsString(entity) + ", repository class = " + ClassUtil.getClassAsString(entityClass));
			
			if (!EntityUtil.hasId(entity)) {
				// The entity has no id - if it is persistedEntities it is MANAGED (it has been persisted but not yet flushed), otherwise it is NEW
				return (persistedEntities[entity]) ? STATE_MANAGED : STATE_NEW;
			} else {
				// The entity has an id, so if it is in the array collection it is MANAGED, otherwise it is REMOVED or DETACHED
				if (entities.findBy(EntityUtil.getIdObject(entity))) {
					return STATE_MANAGED;
				} else {
					return (em.getUnitOfWork().hasRemovedEntity(entity)) ? STATE_REMOVED : STATE_DETACHED;
				}
			}
		}
		
		/**
		 * Add an entity to the repository and setup listeners on it.  This is called on the returned objects from a load operation.
		 * 
		 * @param	entity
		 * @param   temporaryUid This is the temporary uid for entities that have been persisted and not yet flushed.  This is only for debug purposes
		 * 						 and is not used by addEntity.
		 */
		public function addEntity(entity:Object, temporaryUid:String = null):Object {
			var idHash:String = EntityUtil.getIdHash(entity);
			
			log.info("Adding " + entity + " {repository=" + ClassUtil.formatClassAsString(entityClass) + ", " + ((temporaryUid) ? "tempUid=" + temporaryUid : "idHash=" + idHash) + "}");
			
			entities.addItem(entity);
			
			addEventListenerToEntity(entity);
			
			return entity;
		}
		
		/**
		 * Persists an entity to the repository and setup listeners on it.  This is called on the client when persist is called locally (before a flush).
		 * This method generates a unique id for the persisted entity, so that when flush() is called we can match up the returned entity with the persisted
		 * entity and merge in its id value.
		 * 
		 * TODO: Should this check if the entity already has an id and if so merge instead of persist??
		 * 
		 * @param	entity
		 */
		public function persistEntity(entity:Object):String {
			switch (getEntityState(entity)) {
				case STATE_NEW:
				case STATE_REMOVED:
					// New and removed entities can be persisted
					var temporaryUid:String = UIDUtil.createUID();
					
					// Add the entity to the temporaryUidMap so that addEntity can match it up
					temporaryUidMap[temporaryUid] = entity;
					
					// Add the entity to the persisted entities so we don't persist it twice
					persistedEntities[entity] = true;
					
					addEntity(entity, temporaryUid);
					
					return temporaryUid;
				case STATE_MANAGED:
					// If the entity is already managed do nothing
					return null;
				case STATE_DETACHED:
					throw new Error("Behaviour of persist for detached entities is not yet defined");
					break;
			}
			
			return null;
			
			if (getEntityState(entity) == STATE_MANAGED) return null;
			// Check it has not been persisted already
			//if (persistedEntities[entity]) return null;
			
			
		}
		
		/**
		 * When flush() is called and the returned changeset is executed, entity insertions will call this method.  It will first try and match up the entity
		 * with an entity that was persisted on this client and if so merge the changes with it.  If it cannot find the persisted entity in the temporaryUidMap
		 * we assume it was persisted on another client and add to the repository as normal.
		 * 
		 * @param	entity
		 * @param	temporaryUid
		 */
		public function addPersistedEntity(entity:Object, temporaryUid:String):void {
			if (temporaryUidMap[temporaryUid]) {
				// We found the temporary uid in the repository, so upate the existing entity by reference
				log.info("Updating persisted entity " + entity + " with uid " + temporaryUid);
				
				isUpdating = true;
				EntityUtil.mergeEntity(entity, temporaryUidMap[temporaryUid]);
				isUpdating = false;
				
				// Now this entity has a real id we can ditch the temporary one
				delete temporaryUidMap[temporaryUid];
			} else {
				// The temporary id was not found in the repository so this must have been persisted on another client; add the entity as a new object
				addEntity(entity);
			}
		}
		
		public function updateEntity(entity:Object):Object {
			var idHash:String = EntityUtil.getIdHash(entity);
			
			// Find the existing entity
			var existingEntity:Object = entities.findOneBy(EntityUtil.getIdObject(entity));
			
			log.info("Updating " + existingEntity + " to " +  entity + " {repository=" + ClassUtil.formatClassAsString(entityClass) + ", idHash=" + idHash + "}");
			
			isUpdating = true;
			existingEntity = EntityUtil.mergeEntity(entity, existingEntity);
			isUpdating = false;
			
			return existingEntity;
		}
		
		/**
		 * Delete an entity from the repository.  This has extra logic in it that detects if the entity we are removing has not yet been persisted on the
		 * server (i.e. has no id), and if so rolls back the pending persist operation instead of doing a persist then a remove on the server.
		 * 
		 * @param	entity	The entity to delete
		 * @return			true if we actually need to remove the entity on the server, false if the remove is only local
		 */
		public function deleteEntity(entity:Object):Boolean {
			var entityState:String = getEntityState(entity);
			
			var idHash:String;
			var entityToRemove:Object;
			
			switch (entityState) {
				case STATE_NEW:
					throw new Error("This entity is NEW and cannot be deleted from this repository.");
					break;
				case STATE_MANAGED:
					if (EntityUtil.hasId(entity)) {
						// Find the existing entity and set it as the entity to remove
						idHash = EntityUtil.getIdHash(entity);
						entityToRemove = entities.findOneBy(EntityUtil.getIdObject(entity));
					} else {
						// If this is a new entity that has been persisted but not yet flushed then removing it actually means removing it from the array collection,
						// removing it from the temporaryUidMap and removing it from the unit of work (so that it won't be flushed next time).  Since there is no id
						// we have to assume that the 'entity' passed is actually the same instance that is in the repository, as we obviously can't find it by its id
						// and the temporary uid is internal.
						entityToRemove = entity;
						
						// Remove from the temporaryUidMap, persistedEntities and the unit of work
						for (var temporaryUid:String in temporaryUidMap) {
							if (temporaryUidMap[temporaryUid] === entityToRemove) {
								// Remove from the unit of work
								em.getUnitOfWork().undoPersist(temporaryUid);
								
								// Remove from the temporaryUidMap
								delete temporaryUidMap[temporaryUid];
								
								// Remove from persistedEntities
								delete persistedEntities[entity];
								
								break;
							}
						}
					}
					break;
				case STATE_REMOVED:
					// If the entity has already been removed do nothing
					log.info("Remove called on already removed entity " + entity +  " - ignoring {repository=" + ClassUtil.formatClassAsString(entityClass) + ", idHash=" + idHash + "}");
					return false;
				case STATE_DETACHED:
					throw new Error("You cannot remove a detached entity");
					break;
				default:
					throw new Error("Unknown state '" + entityState + "' for entity " + entity);
			}
			
			log.info("Deleting " + entityToRemove +  " {repository=" + ClassUtil.formatClassAsString(entityClass) + ", idHash=" + idHash + "}");
			
			// Remove the change listener
			removeEventListenerFromEntity(entityToRemove)
			
			// Remove the entity from the array collection
			entities.removeItem(entityToRemove);
			
			return (entityState == STATE_MANAGED && EntityUtil.hasId(entity));
		}
		
		/**
		 * Add entityToAdd to the array collection in entity[associationName].  The only reason this is a method of EntityRepository is so that
		 * we can set isUpdating to false whilst making the change therefore not making the object dirty.  addLoadedEntityToRepository makes use
		 * of this when mapping collections to existing objects.
		 * 
		 * @param	entity
		 * @param	associationName
		 * @param	entityToAdd
		 */
		public function addEntityToManyAssociation(entity:Object, associationName:String, entityToAdd:Object):void {
			isUpdating = true;
			entity[associationName].addItem(entityToAdd);
			isUpdating = false;
		}
		
		public function find(id:Number):Object {			
			var idTags:XMLList = EntityUtil.getAttributesWithTag(new entityClass(), MetaTags.ID);
			
			if (idTags.length() > 1)
				throw new FlextrineError("EntityRepository.find() cannot be used on entities with composite keys {repository=" + ClassUtil.formatClassAsString(entityClass) + "}");
			
			var idObject:Object = {};
			idObject[idTags[0]] = id;
			
			return entities.findOneBy(idObject);
		}
		
		public function findAll():Array {
			return entities.toArray();
		}
		
		public function findBy(criteria:Object):Array {
			return entities.findBy(criteria);
		}
		
		public function findOneBy(criteria:Object):Object {
			return entities.findOneBy(criteria);
		}
		
		public function load(id:Number, fetchMode:String = null):AsyncToken {
			return em.getDelegate().load(entityClass, id, (fetchMode) ? fetchMode : em.getConfiguration().fetchMode);
		}
		
		public function loadBy(criteria:Object, fetchMode:String = null):AsyncToken {
			return em.getDelegate().loadBy(entityClass, criteria, (fetchMode) ? fetchMode : em.getConfiguration().fetchMode);
		}
		
		public function loadOneBy(criteria:Object, fetchMode:String = null):AsyncToken {
			return em.getDelegate().loadOneBy(entityClass, criteria, (fetchMode) ? fetchMode : em.getConfiguration().fetchMode);
		}
		
		public function loadAll(fetchMode:String = null):AsyncToken {
			return em.getDelegate().loadAll(entityClass, (fetchMode) ? fetchMode : em.getConfiguration().fetchMode);
		}
		
		/**
		 * Add a listener for property change events; objects in the repository are read only and if there is a property change then we need to dispatch
		 * an error.  We remove the listener first just in case it already has one for some reason, and make sure that we use a weak referenced event
		 * listener to avoid entities staying in memory after they are removed.
		 * 
		 * @param	entity
		 */
		private function addEventListenerToEntity(entity:Object):void {
			removeEventListenerFromEntity(entity);
			entity.addEventListener(PropertyChangeEvent.PROPERTY_CHANGE, onPropertyChange, false, 0, false);
			
			// Go through the associations and when we find one that is a collection add an event listener to mark this object as dirty if elements are
			// added or removed from the collection.  We don't care about UPDATE events within the collection as these will be taken care of by the contained
			// entity's own repository.
			
			// TODO: Since we can't add the event listeners here for lazy loading objects, we need to add them when the object IS loaded
			// TODO: When loading uninitialized collections we can either merge data into an existing array collection or make a new one
			// and re-add the listener - EntityUtil.isCollectionInitialized(entity, associationAttribute).
			if (EntityUtil.isInitialized(entity)) {
				for each (var associationAttribute:XML in EntityUtil.getAttributesWithTag(entity, MetaTags.ASSOCIATION)) {
					if (entity[associationAttribute] is ArrayCollection)
						entity[associationAttribute].addEventListener(CollectionEvent.COLLECTION_CHANGE,
																	  Closure.create(this, onCollectionChange, entity, associationAttribute.toString()),
																	  false, 0, false);
				}
			}
		}
		
		private function onCollectionChange(e:CollectionEvent, entity:Object, attribute:String):void {
			if (e.kind == CollectionEventKind.ADD || e.kind == CollectionEventKind.REMOVE || e.kind == CollectionEventKind.RESET || e.kind == CollectionEventKind.REPLACE)
				entity.dispatchEvent(PropertyChangeEvent.createUpdateEvent(entity, attribute, "Collection: " + e.kind, e.items));
		}
		
		private function removeEventListenerFromEntity(entity:Object):void {
			entity.removeEventListener(PropertyChangeEvent.PROPERTY_CHANGE, onPropertyChange);
		}
		
		/**
		 * Changes are not allowed to be made on any objects in the repository!  The change workflow is to get a writable copy of an entity, make changes on
		 * that, merge it into the repository, then flush the repository.  The only exception is when the EntityRepository itself is updating an object as
		 * part of a changeset received from the server (which is what the isUpdating flag is for).
		 * 
		 * @param	e
		 */
		private function onPropertyChange(e:PropertyChangeEvent):void {
			if (!isUpdating) {				
				// TODO: If this is a NEW entity then we don't want to do anything, so need to detect that somehow, plus if its already merged we probably
				// don't want to do this.
				var entityState:String = getEntityState(e.currentTarget);
				
				switch (entityState) {
					case STATE_NEW:
						throw new Error("There shouldn't be a property change listener on a new object");
						break;
					case STATE_MANAGED:
						// If the entity is managed then we need to mark it dirty and merge it
						log.info("Detected change on managed entity " + e.currentTarget + " - property '" + e.property + "' from '" + e.oldValue + "' to '" + e.newValue + "' {repository = " + ClassUtil.formatClassAsString(entityClass) + "}");
						em.getUnitOfWork().merge(e.currentTarget);
						break;
					case STATE_REMOVED:
						throw new Error("A change was detected on a removed entity; this is an error as there shouldn't be any listeners on the entity anymore!");
						break;
					case STATE_DETACHED:
						throw new Error("A change was detected on a detached entity; this is an error as there shouldn't be any listeners on the entity!");
						break;
				}
			}
		}
		
	}

}